fix(ui): keep the prompt rail clickable on macOS - #2338
Merged
Merged
Conversation
Astro-Han
force-pushed
the
fix/prompt-rail-macos-hit-test
branch
from
August 6, 2026 14:49
3d81dc5 to
6fb561c
Compare
The rail rested flush against the chat scroller's right edge. On macOS the scroller's vertical scrollbar is overlay: it takes no layout space, so the rail drew on top of it, but the scrollbar's hit region still intercepted every pointer — ticks rendered yet never received a click or hover, and the rail read as gone. #2215's translateX(3px) had pushed the tick centres into that band; before it, the ticks were merely grazed. Linux's in-flow scrollbar shifts the content column left instead, which is why the regression sailed through CI green. The rail now rests at right: space-1 + space-2 (12px) — clear of the measured ~14px dead band — with the hover settle-in moved to the `right` property (3px further inward); the vertical centring translateY(-50%) stays, as it only centres vertically and has no part in the hit-region problem. The rail's own scrollbar is hidden: at 22px wide the overlay bar's hit region swallowed tick halves right after the rail scrolled itself (wheel scrolling still works). prompt-rail.spec.ts documents why the reachability assertions are load-bearing on macOS, and the "stays inside the scrollport" test now asserts the platform-neutral geometry the fix rests on (tick bar at least 14px clear of the scroller's right edge — pre-fix it read ~5px). The suite passes 9/9 on macOS under CI's single worker.
Astro-Han
force-pushed
the
fix/prompt-rail-macos-hit-test
branch
from
August 6, 2026 14:49
6fb561c to
0608200
Compare
Astro-Han
marked this pull request as ready for review
August 6, 2026 15:03
ARE404
added a commit
to ARE404/maka-agent
that referenced
this pull request
Aug 13, 2026
apache#2580 moved the rail's tick onto Astryx's Button. The bar the tick draws was a direct child of the flex tick and got blockified; the Button wraps its children in a label span, so the bar went back to normal flow as an inline box. An inline box takes no width or height, so every bar computed to 0x0 and the rail shipped invisible in 0.1.9 and 0.1.10 — present in the DOM, painting nothing. `display: block` on the bar restores it. Measured on the new fixture at 1280x800: the rail's box goes from 8px wide (its own padding, ticks contributing nothing) back to the designed 22px. This is the third time the rail has failed by rendering and not painting — apache#2161 pinned it against a containing block as tall as the conversation, apache#2338 parked it under macOS's overlay scrollbar — and the second time it reached a release. The e2e coverage that would have caught all three was deleted in apache#2462, and the multi-prompt fixtures it ran on in apache#2656, so this adds back the smallest thing that closes the gap: - `chat-prompt-rail`, a plain 8-prompt conversation. The rail hides itself below three prompts, so the shipped single-prompt fixture cannot show it at all. - `prompt-rail.spec.ts` with one test per past failure: bars have a real box, the rail stays inside the scrollport at both scroll extremes, and a tick is what the pointer lands on. Three tests where the deleted suite had nine. Verified the first test fails on the unfixed renderer with "Expected: > 0, Received: 0" and passes with the fix. Neither a static CSS read nor a jsdom unit test can see any of this: jsdom has no layout engine.
Astro-Han
pushed a commit
that referenced
this pull request
Aug 13, 2026
…2923) * fix(ui): give the prompt rail's tick bars a box again #2580 moved the rail's tick onto Astryx's Button. The bar the tick draws was a direct child of the flex tick and got blockified; the Button wraps its children in a label span, so the bar went back to normal flow as an inline box. An inline box takes no width or height, so every bar computed to 0x0 and the rail shipped invisible in 0.1.9 and 0.1.10 — present in the DOM, painting nothing. `display: block` on the bar restores it. Measured on the new fixture at 1280x800: the rail's box goes from 8px wide (its own padding, ticks contributing nothing) back to the designed 22px. This is the third time the rail has failed by rendering and not painting — #2161 pinned it against a containing block as tall as the conversation, #2338 parked it under macOS's overlay scrollbar — and the second time it reached a release. The e2e coverage that would have caught all three was deleted in #2462, and the multi-prompt fixtures it ran on in #2656, so this adds back the smallest thing that closes the gap: - `chat-prompt-rail`, a plain 8-prompt conversation. The rail hides itself below three prompts, so the shipped single-prompt fixture cannot show it at all. - `prompt-rail.spec.ts` with one test per past failure: bars have a real box, the rail stays inside the scrollport at both scroll extremes, and a tick is what the pointer lands on. Three tests where the deleted suite had nine. Verified the first test fails on the unfixed renderer with "Expected: > 0, Received: 0" and passes with the fix. Neither a static CSS read nor a jsdom unit test can see any of this: jsdom has no layout engine. * fix(ui): make the prompt rail's hover and jump behave Four things the rail got wrong once it was visible again, found by using it: - A 4px gap between ticks was a band where the pointer was over the rail and over no tick, so the dock-style hover falloff dropped out and picked up again every few pixels of travel. The rail's `gap` moves into the ticks' own `padding-block`: same pitch, hit boxes now tile. - The hover preview waited 300ms before opening — Astryx's HoverCard default, meant for a pointer crossing a wide row on its way somewhere else. A tick is 22px of rail that nothing is on the way to, and the wait is the one part of this hover with no motion in it. Now 120ms. - The highlight glided 280ms to wherever a click landed, so crossing twenty prompts read as the bar flying off across the rail. A click now owns the highlight until its scroll settles: no glide, and the scroll no longer walks the highlight through every prompt it passes. - The first click into a session did nothing until the reader scrolled by hand. See below. That last one is a collision between Astryx's auto-follow lock and the progressive transcript mount, and neither side is wrong on its own. `useChatStreamScroll` unlocks on a scroll up, detected by comparing scrollTop between events — but it ignores any scroll event that arrives with a changed scrollHeight or offsetHeight, because Chrome fires those when content resizes and they are not the reader moving. A jump into an unmounted turn mounts it and the fill that follows changes scrollHeight for several frames, so the jump's own scroll is invisible to the lock: it stays on, and `scrollIfLocked` pulls the transcript back to the bottom. Only a wheel gesture broke it, which takes a separate path in Astryx. `holdJumpDestination` re-aims at the target on each height change until the fill stops. The last of those scrolls lands with a stable height, which is the one the lock finally reads as a scroll up. Measured on the 30-prompt fixture: clicking the first tick went to scrollTop 7042 (the bottom) and now goes to 24 and holds. The fixture grows from 8 prompts to 30 because the progressive mount's initial window is 10 — at 8 the head of the transcript is already mounted and the jump-into-unmounted-turns path never runs at all. Coverage note: the e2e case for the first click is an end-to-end check, not a guard. Whether the lock wins depends on which frame the fill lands on relative to a smooth scroll still in flight, and it goes green against the unfixed renderer often enough to be worthless as one. The guard is the `holdJumpDestination` unit test, which drives the frames itself. * fix(ui): own a rail jump through the mount instead of racing it Review of #2923 found the jump's ownership bound to a clock rather than to the navigation, and the e2e case that was supposed to guard it asserting almost nothing. Both hold. Jump ownership: - A second click during a jump only replaced the target; the first click's 700ms timer still governed, and could clear the second jump mid-flight. Each click now carries its own sequence and starts its own hold. - The fixed window is gone. A hold runs until the progressive mount reports the transcript filled AND nothing has moved for a few frames, so a long transcript is never released mid-fill, and it ends the moment the reader touches the transcript (wheel, touch, pointer, key) rather than outliving their interest in it. Chasing the "just release auto-follow" direction the review preferred found that ChatLayout publishes no such seam, so this adds one — `unlockAutoFollow` on `ChatLayoutContextValue`, exposing the scroll hook's existing `unlock` (patch hunk + patches/README entry). It is necessary and it is not sufficient, which the earlier framing got wrong: - Astryx re-locks on any `scrollend` that settles near the bottom, and a session that opens at the bottom produces exactly that while the mount is still catching up. Releasing once at the click is undone before the jump goes anywhere — traced: released at the click, landed at 154ms, dragged back to the bottom by 166ms. The release is now re-asserted for the life of the hold. - Auto-follow is not the only thing moving the transcript. The progressive mount's own scroll compensation holds the reader's position across each fill step, and mounting the turn a jump asked for IS a fill step, so it lands after the jump and restores the position the jump just left. That one no seam can fix; it is what the hold is for. Jumps also scroll instantly now, whatever the app's scroll-motion policy says. A jump is a teleport the reader asked for, and an animated one does not survive this surface: traced on the 30-prompt fixture, the smooth scroll was cancelled by the mount's compensation and by the follow spring and stalled two pixels from where it started. Coverage: - The first-click e2e case named the wrong turn (`[data-turn-id]` is the first MOUNTED turn, whose top is already negative at the opening scroll position, so an upper-bound-only check passed without the jump doing anything). It now names `turn-prompt-rail-1`, bounds it on both sides, and asserts that tick's `aria-current`. - `emulateMedia` could not put that case on the production scroll path: `resolveScrollMotionBehavior` collapses motion for ANY fixture, keyed on `data-maka-e2e-fixture` rather than on the media query. Fixtures can now ask for a behavior back (`scrollMotion`, per launch — it costs seconds of settling per window, so only the case that needs it pays), with unit coverage for the precedence: a fixture request never outranks a stated preference for less motion. - `holdJumpDestination`'s unit tests grew the two cases its rewrite is about: it must not settle while the transcript is still filling, and it must hand the transcript back the moment the reader touches it. Verified 5/5 on the smooth-scroll fixture, where the previous revision lost 1 in 4. `quote-selection.spec.ts` flakes on this machine (1 in 4) at upstream/main as well, unchanged by this branch.
6 tasks
Astro-Han
added a commit
that referenced
this pull request
Sep 4, 2026
#4741 removed prompt-rail.spec.ts along with transcript-scroll.spec.ts, and for the same reason. This finishes #4761's P0 by putting its assertions in the same tier as the previous two commits: ten of the eleven, one waiting on shell state. That spec existed because the rail failed three times in a row the same way — the code kept working and the pixels stopped. #2161 pinned it against .maka-chat-shell while Astryx's ChatLayout owned the scroll container, so it laid out across the whole conversation and scrolled off screen. #2338 parked it under macOS's overlay scrollbar, which takes no layout space but still swallows the pointer, so every tick rendered and none could be clicked. #2580 moved the tick onto Astryx's Button, whose label span put the bar back into normal flow, and an inline box takes no width or height, so the bars computed to 0x0 and shipped invisible in 0.1.9 and 0.1.10. None of the three is visible to a static read of the CSS and none is reachable from jsdom. All three need a real scroller with a real transcript. None needs Electron. The 120-prompt seeded session turned out not to be needed either. ChatView reads two props: the transcript carries only the Host's bounded active range, and transcriptTurnIndex carries the remaining landmarks. So the rail gets its full 64 ticks against 10 mounted Turns, which is what production does. A tick for a Turn outside the range comes back out as onLoadTranscriptTurn, so the jump that used to look dead — the head not mounted, the fill changing scrollHeight under the tail-follow lock — is reachable by moving the range in the harness. Two things a green run here does not mean. PromptRailTickOwnsItsOwnHitBox guards #2338, and it is load-bearing on macOS only: Linux's in-flow scrollbar moves the content column left instead of overlaying it, so the regression goes green on CI. That was already true in E2E. The comment says to run it locally on macOS before touching the rail's right edge. RailStaysOnTheVisiblePrompt no longer walks all 120 prompts of history. It asserts at five reading positions plus one jump that replaces the active range, keeping both original assertions — exactly one current tick, and it maps from the Turn being read — with a MutationObserver watching the count across every change rather than sampling at rest. Verified by mutation: offsetting the expected tick index by one fails it. Switching Sessions and rebuilding only the Host active range is not here. It needs shell state app-shell.tsx holds (#4582). Refs #4761. Generated-by: Claude Code
This was referenced Sep 4, 2026
Astro-Han
added a commit
that referenced
this pull request
Sep 4, 2026
An ablation over the three commits above: take out everything the stories can lose without losing coverage. 1225 lines to 1129, of which comments are 245 to 190. PromptRailTickOwnsItsOwnHitBox is gone because PromptRailHasNoGapsBetweenTicks already contains it. Both ask elementFromPoint what is under the rail. The hit-box story asked once, at the first tick's centre, and accepted any .maka-prompt-rail ancestor; the gaps story asks at every pixel from the first bar's centre to the last and requires a .maka-prompt-rail-tick. The tick lays its bar out with justify-content: flex-end, so the column the gaps story walks is the one nearest the overlay scrollbar — which is where #2338 hides. The macOS caveat moved onto the assertion that now carries it. The rail's fixture is gone as a fixture. promptRailMessagesFrom built the same Turns transcriptTurns already built, under a second turnId prefix; there is one generator now, and the prompt text is the label the rail indexes, which is what OffscreenActiveTurnsStayFindable searches for. What is left in the comments is what someone changing an assertion has to know: which mechanism it rides on, why a bound rather than an equality, and where a green run does not mean what it looks like. Why each regression happened is in the three commits above and in the issues they name, so it is not repeated at the assertion. Storybook smoke: 278 stories / 304 theme renders, all nineteen new stories among them. Refs #4761. Generated-by: Claude Code
Astro-Han
added a commit
that referenced
this pull request
Sep 4, 2026
Two review passes over the four commits above — one mutation-testing every assertion, one auditing the ported assertions against the deleted specs. Twelve stories were confirmed to go red when the defect they name is put back. What follows is what did not survive. ReaderScrolledUpIsNotPulledBack was genuinely flaky: 4 failures in ~310 runs at 4x CPU throttle, always "expected 16 to be less than or equal to 4". Its waitFor exited the instant the transcript grew, and growth crosses that threshold while the arriving Turn is still laid out at its content-visibility estimate — so the anchor was read outside any retry, on an intermediate layout. Both conditions retry together now. Retrying cannot launder a real failure: a reader who was pulled back sits at the tail, so the growth condition never holds again. Verified both ways — 0 red in 40 runs at 4x and 60 at 8x, and still red on the unconditional-writeToTail mutation. StreamingDeltasKeepThePromptRailObserver is gone. It installed its IntersectionObserver probe after the rail's observer already existed, so nothing proved the patched subclass was ever the one the rail built: changing the rootMargin literal it matches on leaves it green with the regression in place. It also had no geometry in it — it counted constructions and matched a string — so by this PR's own criterion it is a component test, not a story, and it was the slowest of the set (10.9s at 8x against a 15s budget). Removed rather than patched; #4761 carries it. Three assertions had been dropped without being named. StreamingTailFollow asserts again that the dock is not offered to a reader the tail never left. FirstRailClickLandsOnItsPromptAndHolds asserts again that motion is not collapsed — the bug it guards only exists while a scroll is in flight, and the fixture's own scrollBehavior is smooth, so the browser's reduced-motion state is the one thing left that can hollow it out. The third, OffscreenActiveTurnsStayFindable's accessibility-tree half, needs CDP and cannot come across; the comment claiming the smoke's AX audit covers it was wrong and now says so, and the PR body lists it as a gap. EarlierHistoryLandsAboveTheReader fixes its budget once the arrival has settled instead of recomputing it on every retry, where it would have grown along with the drift it bounds. PromptRailHasNoGapsBetweenTicks now requires its walk to have covered most of the rail, which the old last.bottom > first.top could not fail. SMOKE_HEADED=1 is the one change outside the stories. The #2338 comment told the reader to run the story locally on macOS; the smoke launches headless Chromium, which paints no platform scrollbar, so that instruction bought nothing. Measured while checking it: the overlay scrollbar's hit region is 1-14px from the scrollport edge, this story's walk is at 11px, and the story removed in the previous commit probed at 17px — outside it. Removing it was right; the reason given for it was not. Storybook smoke: 277 stories / 303 theme renders. Refs #4761.
Astro-Han
added a commit
that referenced
this pull request
Sep 4, 2026
Two review passes over the four commits above — one mutation-testing every assertion, one auditing the ported assertions against the deleted specs. Twelve stories were confirmed to go red when the defect they name is put back. What follows is what did not survive. ReaderScrolledUpIsNotPulledBack was genuinely flaky: 4 failures in ~310 runs at 4x CPU throttle, always "expected 16 to be less than or equal to 4". Its waitFor exited the instant the transcript grew, and growth crosses that threshold while the arriving Turn is still laid out at its content-visibility estimate — so the anchor was read outside any retry, on an intermediate layout. Both conditions retry together now. Retrying cannot launder a real failure: a reader who was pulled back sits at the tail, so the growth condition never holds again. Verified both ways — 0 red in 40 runs at 4x and 60 at 8x, and still red on the unconditional-writeToTail mutation. StreamingDeltasKeepThePromptRailObserver is gone. It installed its IntersectionObserver probe after the rail's observer already existed, so nothing proved the patched subclass was ever the one the rail built: changing the rootMargin literal it matches on leaves it green with the regression in place. It also had no geometry in it — it counted constructions and matched a string — so by this PR's own criterion it is a component test, not a story, and it was the slowest of the set (10.9s at 8x against a 15s budget). Removed rather than patched; #4761 carries it. Three assertions had been dropped without being named. StreamingTailFollow asserts again that the dock is not offered to a reader the tail never left. FirstRailClickLandsOnItsPromptAndHolds asserts again that motion is not collapsed — the bug it guards only exists while a scroll is in flight, and the fixture's own scrollBehavior is smooth, so the browser's reduced-motion state is the one thing left that can hollow it out. The third, OffscreenActiveTurnsStayFindable's accessibility-tree half, needs CDP and cannot come across; the comment claiming the smoke's AX audit covers it was wrong and now says so, and the PR body lists it as a gap. EarlierHistoryLandsAboveTheReader fixes its budget once the arrival has settled instead of recomputing it on every retry, where it would have grown along with the drift it bounds. PromptRailHasNoGapsBetweenTicks now requires its walk to have covered most of the rail, which the old last.bottom > first.top could not fail. SMOKE_HEADED=1 is the one change outside the stories. The #2338 comment told the reader to run the story locally on macOS; the smoke launches headless Chromium, which paints no platform scrollbar, so that instruction bought nothing. Measured while checking it: the overlay scrollbar's hit region is 1-14px from the scrollport edge, this story's walk is at 11px, and the story removed in the previous commit probed at 17px — outside it. Removing it was right; the reason given for it was not. Storybook smoke: 277 stories / 303 theme renders. Refs #4761. Generated-by: Claude Code
Astro-Han
added a commit
that referenced
this pull request
Sep 4, 2026
…#4766) #4741 deleted `transcript-scroll.spec.ts` (774 lines) and `prompt-rail.spec.ts` (622) because they asserted scroll offsets and bounding boxes against a compositor settling on its own schedule, and blocked `main` while doing it. That was the right call for the required check, and it left transcript scrolling and the prompt rail with no automated coverage at all — including the three regressions the rail spec existed for, each of which shipped: the rail laid out across the whole conversation and scrolled off screen (#2161), parked under macOS's overlay scrollbar where every tick rendered and none could be clicked (#2338), and computed to 0x0 bars, invisible in 0.1.9 and 0.1.10 (#2580). The instability was never in the readings. It was in asserting them under four Xvfb workers sharing OS focus and throttled frames. These assertions need a real layout engine; none of them needs Electron. Storybook renders in the same Chromium, settles per story rather than per application launch, and has nothing competing for focus. Rebuilds 18 of the 22 deleted tests as Storybook `play` functions, in eighteen stories, plus one that is new. No production code changes. Nothing had to be extracted first: `ChatView` already takes the transcript, the history seam, the growth signal and the rail's landmark index as props, so a story reaches every one without a fake backend. The rail's 120-prompt seeded session was not needed either — the transcript carries the Host's bounded active range and `transcriptTurnIndex` carries the rest of the landmarks, so the rail gets its full 64 ticks against 10 mounted Turns, which is what production does. The nineteenth story is not a restoration. Every deleted test watches content arriving at a reader who stays put; none watches a reader travelling *up* through `content-visibility` placeholders as they materialise, which is where #4259 measured one traversal moving `scrollHeight` by 63%. Measuring first changed what it could assert: the traversal is not still and cannot be, since a Turn off screen is laid out at its `contain-intrinsic-block-size` estimate and swaps to its real height on the way past. So the story bounds it — no single step past a whole Turn, the whole traversal within 15%, and the reader can still dock back. That bound is the property #4206 bought: one estimate to correct per Turn, so the correction scales with Turns crossed rather than with what is inside them. Four of the deleted tests are not rebuilt, for three different reasons: - *a gesture a nested scroller consumed does not release the tail* — a tier decision. It turns on Chromium's own scroll chaining, which needs real wheel input. Stays in E2E. The sibling case sinks because its guard reads `composedPath()` and the overflow of what the wheel crossed, which is DOM state a dispatched wheel reaches identically. - *streaming deltas do not reconstruct the prompt rail observer* — uncovered until it lands as a component test (#4761). It asserts no geometry, so a layout engine buys it nothing. - *switching Sessions restores a Turn anchor* and *switching sessions reconstructs only the Host active range* — uncovered, waiting on #4582. Not a tier decision: they need shell state `app-shell.tsx` holds, not a real window. Three ported assertions are not byte-for-byte, and the description lists them rather than leaving them to be discovered: the accessibility-tree half of the offscreen-Turn test needs CDP and did not come across; the nested-scroller test swapped its closing tail-follow check for a direct assertion that no history was requested; and the rail-currency test asserts at five reading positions plus a range-replacing jump with a MutationObserver, rather than walking all 120 prompts. Twelve of the stories were mutation-tested against the real defect they name — `display: inline` on the tick bar, `position: static` on the rail anchor, both directions of the tail-follow ResizeObserver branch, dropping the wheel guard, `contain-intrinsic-block-size: 60px`, `content-visibility: hidden` — and go red for the right reason. One story was found genuinely flaky under review (4 failures in ~310 runs at 4x CPU throttle) because it read its anchor outside any retry, on a layout the arriving Turn had not finished; fixed and re-checked at 0 in 40 runs at 4x and 60 at 8x, still red under mutation. One change outside the stories: `SMOKE_HEADED=1` on the visual smoke runner. The #2338 guard is inert headless — headless Chromium paints no platform scrollbar, and Linux's in-flow one moves the content column left instead of overlaying it — so "run it locally on macOS" was an instruction that bought nothing until there was a way to run it headed. No migration or compatibility impact. Storybook gains eighteen stories that exist to be asserted against rather than looked at. Refs #4761. Generated-by: Claude Code
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
问题
Prompt Rail 在新 main 上"消失"了:rail 正常渲染,但所有 tick 点击/hover 无响应。本地 macOS 上 prompt-rail E2E 3 挂 6 过,CI(Linux)93 个测试全绿。
根因(经对抗性审查与实测修正)
rail 贴死在 chat scroller 右缘(
right: 4px+translateX(3px))。macOS 的 scrollbar 是 overlay——不占布局空间,rail 画在它上面,但scrollbar 命中区域依然拦截指针:translateX(3px)把 tick 中心(1221→1224)推进死区 → 全灭;fix(ui): pin the prompt anchor rail to the Astryx chat scrollport #2161 之前只是擦边;修复(2 个文件,+51/-18,组件零改动)
right: calc(space-1 + space-2)(12px,实测死区外,余量 8px);hover 向内微移 3px 改用right(不再用 translateX 推入死区);保留translateY(-50%)纯 CSS 垂直居中(垂直位移与水平命中无关,JS 测高方案已废弃);隐藏 rail 自身 scrollbar(22px 宽的 rail 不需要可见滚动条,wheel 滚动保留)。验证
prompt-rail.spec.tsmacOS 单 worker(CI 配置)9/9 通过(修复前 3 挂 6 过);对抗性审查结论(独立子代理)